Skip to content

Claiming: sign in with a passkey, publish a token, be verified - #16

Merged
HarryCordewener merged 3 commits into
feat/reference-pagesfrom
feat/claiming
Jul 31, 2026
Merged

Claiming: sign in with a passkey, publish a token, be verified#16
HarryCordewener merged 3 commits into
feat/reference-pagesfrom
feat/claiming

Conversation

@HarryCordewener

Copy link
Copy Markdown
Member

Stacked on #11. Spec §8 rewritten around one decision — sign in first, then claim — and built end to end.

The decision that shapes everything

The token cannot be a secret, because we ask operators to publish it on a connect screen or in an MSSP field, where every anonymous connection reads it — including every other crawler. So a bearer model, where holding the token confers the claim, is broken the instant it succeeds.

It is a nonce. It proves somebody with write access to that server published it; the account it is bound to answers who asked. Mallory reads Alice's token off the connect screen and can do nothing with it — there is a test named for exactly that.

That also answers what happens between minting and verifying: nothing needs writing down. The pending claim is durable server-side state, shown for as long as it is pending, with each channel's line ready to copy. IssueAsync returns the existing token rather than replacing it, so a refresh cannot invalidate what an operator has just finished pasting into mush.cnf.

Sign-in is passkeys and nothing else

No passwords, no email, no federated provider. The usual blocker for passwordless is account recovery, and it does not apply here: the root of trust is the server the operator controls, not the credential. Lose every device, publish a fresh token, done. An account is correspondingly worth almost nothing to steal.

Identity runs over Dapper, not EF CoreIUserStore + IUserPasskeyStore is a page of SQL, and migrations/0007_ownership.sql stays the single description of these tables. IUserPasswordStore, IUserEmailStore, lockout and two-factor are deliberately unimplemented: each absent interface is a feature we do not have, and adding the password store would make UserManager start offering flows this site has no pages for.

One script file, and it is the only JavaScript on this site. navigator.credentials has no scripting-off path, so the boundary is drawn at sign-in; the catalogue, game pages, archive, plain mode and API all keep working with scripting disabled.

Rules with teeth

  • Presence establishes, absence never revokes (§8.4). Two timestamps, because they are two facts. Absence-revokes hands revocation to any transient failure — this project has watched MCCP swallow a connection's payload whole.
  • Three guarantees live in the schema, not a handler: a claim carries a NOT NULL account; one account holds at most one pending claim per game (partial index, so a verified or revoked claim does not lock an account out of a game it can still prove control of); a token is unique table-wide, without which one game's published token could complete another's.
  • DNS TXT is deferred, and not for want of a resolver: it proves control of a hostname, and MU* hosting routinely puts many unrelated games on one domain separated only by port. The host's operator could claim all of them.
  • Accounts exist only with a database. The claim surfaces are absent rather than broken over the demo fixture — half a flow over invented games would have an operator publishing a token for a listing that is not their game.

Two gaps closed rather than worked around

IGameQueries had no by-id lookup, so an owner page had to reach past the interface or resolve a slug it was never given — a claim binds to the game, not to a name a rename can move. And the test harness had never rendered a whole page; Render.PageAsync wires the fixture and deliberately not the account services, which is the condition every claim-surface test is about.

Verified against a real database

Migration 0007 applies; /account/sign-in, /g/{slug}/claim and /account all answer; the game page offers the claim only when there is somewhere to put it; the assertion endpoint returns a real WebAuthn challenge.

Five suites, 652 tests, zero warnings.

Known and deliberate

Passkeys:ServerDomain is unset in development, so the RP ID falls back to the host. It must be configured before deployment — a passkey is bound to a domain, which gives §15.1's open domain question a deadline: credentials registered before the domain settles have to be registered again after it moves.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NrGKmKcRCGktyhRTFbQDMk

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2c687ee9-7e8d-43c3-89f4-683d2577b015

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@HarryCordewener
HarryCordewener changed the base branch from main to chore/park-the-importer July 31, 2026 15:18
HarryCordewener and others added 3 commits July 31, 2026 10:31
…eans

Spec §8 rewritten around one decision -- sign in first, then claim -- and the
storage and crawler halves built against it. The web half (passkeys, the pages)
is not here yet.

The ordering is not a convenience. We ask an operator to publish the token on a
connect screen or in an MSSP field, where every anonymous connection reads it,
so a design in which holding the token confers the claim is broken the instant
it succeeds. The token is a NONCE: it proves somebody with write access to that
server published it, and the account column answers the separate question of who
asked. Mallory reading Alice's token off the connect screen can do nothing with
it, and there is a test that says so by name.

That also answers what happens between minting and verifying. The pending claim
is durable server-side state shown on the claimant's dashboard, so nobody has to
write a token down or finish in one sitting -- a scheme that punished the person
who closed the tab would put a transcription error between an owner and their
listing.

Sign-in will be passkeys and nothing else (§8.2). No passwords, no email, no
federated provider. The usual blocker for passwordless is account recovery, and
it does not apply here: the root of trust is the server the operator controls,
so losing every device is recoverable by publishing a fresh token. Two
consequences are written down rather than discovered later -- sign-in will be the
only part of this site needing JavaScript, and a passkey is bound to a domain,
which gives §15.1's open domain question a deadline.

§8.4 is the other rule with teeth: presence establishes, absence never revokes.
Two timestamps, because they are two facts. Absence-revokes would hand
revocation to any transient failure, and this project has watched MCCP swallow a
connection's payload whole -- a silent unclaiming on that basis is
indistinguishable from an owner walking away.

Three guarantees are in the schema rather than in a handler, because a
constraint that fires is a guarantee and a branch in C# is an intention: a claim
carries a NOT NULL account, one account holds at most one pending claim per game
(partial unique index), and a token is unique across the table -- without which
one game's published token could complete another game's claim.

DNS TXT is deferred, and not for want of a resolver: a TXT record proves control
of a hostname, and a hostname is not a game. MU* hosting routinely puts many
unrelated games on one domain separated by port, so the host's operator could
claim all of them and a game on somebody else's domain could not use the channel
at all.

Also here, because it was in the way: PostgresFixture ran Postgres at its default
100 connections, and this suite passed that the day it grew past roughly that
many tests. The failure was not a clean one -- whichever tests happened to be
starting failed with 53300, in twenty unrelated places, reading as flakiness
rather than as a ceiling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ASP.NET Core Identity's default store is EF Core. Bringing it in for four tables
would be the wrong trade in a codebase that has kept its SQL visible and its
migrations hand-numbered, and the whole of what Identity needs is two interfaces
-- IUserStore and IUserPasskeyStore -- which is a page of SQL. migrations/0007
stays the single description of these tables.

Only what this app uses is implemented. There is no IUserPasswordStore, no
IUserEmailStore, no lockout and no two-factor store, because there are no
passwords, no addresses and no second factor -- a passkey is a primary factor
(§8.2). An unimplemented interface here is a feature we do not have rather than a
gap: adding the password store would make UserManager start offering flows this
site has no pages for.

The schema gained two columns after reading Identity's actual contract rather
than guessing at it: UserPasskeyInfo carries client_data_json and
is_user_verified, and a store that drops half a record hands back something that
is not what was registered. Transports is a text[] so a round trip cannot
re-delimit what the authenticator reported.

Four schema guarantees are now pinned where they are enforced. Deleting an
account takes its passkeys and is refused while it still owns a claim -- the
asymmetry is deliberate, because a passkey is a way in and a claim is what tells
the world a game is owned. The pending-claim index is partial, so a verified or
revoked claim does not lock an account out of a game it can still prove control
of. And the audit vocabulary is checked in both directions: a CHECK that refuses
a value the code can produce fails in production rather than in a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ified

The web half of §8. Sign-in is passkeys and nothing else -- no passwords, no
email, no federated provider -- over ASP.NET Core Identity's built-in WebAuthn
support, with the Dapper stores from the previous commit behind it.

Four endpoints and one script file. That script is the only JavaScript on this
site, and the boundary is deliberate: navigator.credentials has no scripting-off
path, so the part that needs it is the part used by people who administer a game
server, while the catalogue, the game pages, the archive, plain mode and the API
all keep working with scripting disabled.

The claim page answers the question the design turns on -- what happens between
minting a token and verifying it. Nothing needs writing down. The pending claim
is durable server-side state shown for as long as it is pending, with each
channel's line ready to copy, and IssueAsync returns the existing token rather
than replacing it, so a refresh cannot invalidate what the operator has just
finished pasting into mush.cnf.

Everything to do with accounts is registered only when a connection string is,
and the surfaces are absent rather than broken without one. Half a claim flow
over invented games would be worse than none: an operator following it would
publish a token on a real server for a listing that is not their game.

Two things were missing and are now here rather than worked around.
IGameQueries had no by-id lookup, so an owner page had to reach past the
interface to a store or resolve a slug it was never given -- a claim is bound to
the game, not to a name a rename can move. And the test harness had never
rendered a whole page; Render.PageAsync wires the fixture and deliberately not
the account services, which is the condition every claim-surface test is about.

Verified against a real database rather than a build: migration 0007 applies,
/account/sign-in, /g/{slug}/claim and /account all answer, the game page offers
the claim only when there is somewhere to put it, and the assertion endpoint
returns a real WebAuthn challenge.

Five suites, 652 tests, zero warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@HarryCordewener
HarryCordewener changed the base branch from chore/park-the-importer to feat/reference-pages July 31, 2026 15:32
@HarryCordewener
HarryCordewener merged commit 0854234 into main Jul 31, 2026
1 check passed
@HarryCordewener
HarryCordewener deleted the feat/claiming branch July 31, 2026 15:40
HarryCordewener added a commit that referenced this pull request Aug 15, 2026
* Claiming was complete, tested, and wired to nothing

§8.5 says nothing sets game.is_claimed, so the listing badge and §7.5's ceiling
grace have never been exercised. Claiming shipped in #16 and the note still read
true, so I went looking for why. The claim logic was not the problem — it is
complete and it has tests. The composition was.

The site has two compositions of the same objects. mui-crawl builds the crawl
loop by hand and passes every collaborator; the deployed site assembles it
through DI. Three things were wrong with the second, and none of them could fail
loudly:

CrawlCycle takes its ClaimService as an OPTIONAL parameter, deliberately — a
crawl with no database should do slightly less rather than refuse to run. The
crawler graph registered no ClaimService, so a crawler-only deployment settled
no beacons and said nothing about it.

ClaimService was registered scoped, and the crawl loop that needs it is a
singleton BackgroundService. A scoped dependency is one CrawlCycle can never
legally be given: with scope validation on, which is what `dotnet run` does, the
container refuses to build and THE SITE DOES NOT START with a connection string
set. Production leaves validation off, so there it worked — by accident, and
only there. That is also the answer to §8.5's note: is_claimed was being set in
production and nowhere else.

IClaimStore was registered nowhere at all. Account.razor service-locates it and
reads a null as "this site has no database", so every operator's dashboard was
empty on a site that had their claims, and /g/{slug}/claim/check threw on
request. A service-located dependency fails silently by construction.

Both services are stateless over a pooled NpgsqlDataSource, so both are now
TryAddSingleton, registered by the crawler graph and the accounts graph alike —
the one deployment that runs both gets one of each.

CompositionTests resolves the graph Program builds, under scope validation, in
both environments, and asserts the claim path is really joined. It fails on the
parent commit in seven ways and is the only kind of test that could have caught
any of this: every part was correct and the wiring between them was not.

§8.5's closing note is rewritten to say what is actually true, and to name the
general hazard — an optional dependency and a service-located one both fail
silently when the composition is wrong, and the claim path has one of each.

830 tests over five suites, Postgres exercised. Testcontainers 4.13.0 -> 4.14.0
is the same one-line pickup as #26: SSH.NET 2025.1.0 is now advised against and
NU1903 fails restore on main without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Compose the test from Program's own graph, and make "look sooner" look

Review findings on the composition fix. All four held; each is verified rather
than taken.

CompositionTests restated Program's registrations instead of running them, so a
future divergence — a scoped service consumed by a singleton, AddMuiAccounts
moving — would break the site while all the tests passed. That is exactly the
failure this file exists to catch. The graph moves out of Program's top-level
statements into SiteComposition.AddMuiSite/UseMuiSite, which the deployable and
the test now both call, so there is one copy and nothing to diverge from.

The on-demand check moved nothing. RequestCheckAsync wrote last_checked_at and a
check_requested event; due-ness comes only from crawl_target.next_probe_at, so
no probe ever came of it while the page said the button dialled a real server. A
rate limiter on an action that does not happen is the most convincing possible
no-op. IOnDemandProbes brings the game's targets forward with LEAST — an ask can
only make a probe sooner — and the crawl loop still does the dialling under
CRAWL DELAY and §7.2's address gate. Five Postgres tests assert on the schedule
rather than on the audit log. The button now says what it does: "Look sooner",
brings your game to the front of the queue, we dial on our own schedule.

The remark on TheHostedCrawlerCanBeResolvedInProductionToo described a failure
that never happened — it passes on the parent commit, because Production leaves
scope validation off and resolved the scoped ClaimService from the root. It is a
control, not a finding, and now says so. Measured: the original seven tests fail
five ways on the parent, not seven. With the four added here, ten fail seven.

"A crawler-only deployment" justified the new registrations in three places and
describes nothing: AddMuiCrawler has exactly one caller, MUI.Web's Program, and
mui-crawl builds its graph by hand. §4.11 has one deployable. The real consumer
is the web tier's in-process CrawlCycle, and all three now say so.

Two more while here. The demo composition had no test at all; it has one, and it
asserts the claim surfaces are absent rather than broken and that the page still
admits nothing on it was measured. And the web tier registered its own
NpgsqlAvailabilityStore with AddSingleton, so the crawler's TryAdd was skipped
and the concrete type and IReachableHistory pointed at a second instance —
harmless on one pool and a direct contradiction of the crawler's own comment
that two would be two connection paths answering one question. AddPostgresCatalogue
is TryAdd throughout now, and one object answers to all three names.

838 tests over five suites, Postgres exercised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Drive the passkey ceremony for real, because nothing ever had

#30 reported that a JSON POST to a minimal API mapped after UseAntiforgery() is
refused with 400 in a slim host, even with .DisableAntiforgery(), which would
mean passkey sign-in is broken and §8.2 leaves no other way in.

IT DOES NOT REPRODUCE. Measured against the real host — accounts registered,
Postgres behind them, migrations applied, ASPNETCORE_ENVIRONMENT=Production:

  POST /account/passkey/assertion-options     -> 200
  POST /account/passkey/sign-in  (as sent)    -> 401
  POST /account/passkey/registration-options  -> 200
  POST /account  (Razor route, no token)      -> 400   <- control

The control is the part that makes the rest mean anything: anti-forgery IS live
in that pipeline and refuses an untokened POST one route over. The 401 is the
handler answering, not the middleware — the first attempt without the ceremony's
cookie got a 500 from SignInManager saying no assertion was underway, which is
the handler too.

The mechanism: a minimal API is given anti-forgery metadata only when it binds
FORM data. These bind JSON or a query string, so they carry none and the
middleware passes them through; MapRazorComponents puts metadata on component
routes, which is why the control is refused. And .DisableAntiforgery() sets
RequiresValidation false — a 400 surviving it was never anti-forgery's.

Adding the test anyway. Sign-in is the only door in the building and no suite
opened it: the ones that exercise sign-in are the ones that stub it, and the
composition tests stop at the graph. These drive the real routes through
AddMuiSite/UseMuiSite, with a control that fails if anti-forgery ever stops
being live and would catch the reported failure if it ever became real.

841 tests over five suites, Postgres exercised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant